spb/satelliteindex
Public
TypeScript 66.5%
Python 30.9%
JavaScript 1.4%
CSS 0.7%
1import type { Metadata } from 'next';2import Link from 'next/link';3import { notFound } from 'next/navigation';4import { Donut, HBars, StackedBars } from '@/components/charts/charts';5import { Block, EventsList, ExternalLink, HeroFacts, KpiStrip, PlannedUnavailable, Tag, entityMetadata } from '@/components/entities/shared';6import { ConstellationsMiniTable, LaunchesTable, SatellitesTable, SitesTable } from '@/components/entities/tables';7import { Container } from '@/components/ui/section';8import { Unavailable } from '@/components/ui/unavailable';9import { ApiError, api } from '@/lib/api';10import { fmtDate, fmtDateTime, fmtInt, num, titleCase } from '@/lib/format';11import { MISSION_LABELS, ORBIT_CLASS_COLORS, SITE_URL, STATUS_COLORS, routes } from '@/lib/site';12import type { OperatorDetail } from '@/lib/types';1314type Props = { params: Promise<{ slug: string }> };1516async function load(slug: string): Promise<{ d: OperatorDetail; generatedAt: string } | null> {17 try {18 const res = await api.operator(slug);19 return { d: res.data, generatedAt: res.meta.generated_at };20 } catch (e) {21 if (e instanceof ApiError && e.notFound) return null;22 throw e;23 }24}2526export async function generateMetadata({ params }: Props): Promise<Metadata> {27 const { slug } = await params;28 const r = await load(slug).catch(() => null);29 if (!r) return { title: 'Operator not found', robots: { index: false } };30 const { d } = r;31 return entityMetadata({32 title: `${d.name} — ${fmtInt(d.active_payloads)} active satellites, ${titleCase(d.kind).toLowerCase()}${d.country_name ? ` (${d.country_name})` : ''}`,33 description: `${d.name} operates ${fmtInt(d.active_payloads)} active payloads out of ${fmtInt(d.total_payloads)} launched across ${fmtInt(d.launches)} launches since ${fmtDate(d.first_launch)}. Fleet by status, orbit and mission, constellations, launch history and growth.`,34 path: routes.operator(d.slug),35 });36}3738export default async function OperatorPage({ params }: Props) {39 const { slug } = await params;40 const r = await load(slug);41 if (!r) notFound();42 const { d, generatedAt } = r;4344 const statusData = d.status_distribution.map((s) => ({ label: titleCase(s.status.toLowerCase()), value: num(s.count) ?? 0, color: STATUS_COLORS[s.status] ?? 'var(--other)' })).filter((x) => x.value > 0).sort((a, b) => b.value - a.value);45 const orbitData = d.orbit_distribution.map((o) => ({ label: o.orbit_class, value: num(o.count) ?? 0, color: ORBIT_CLASS_COLORS[o.orbit_class] ?? 'var(--other)' })).filter((x) => x.value > 0).sort((a, b) => b.value - a.value);46 const missionData = d.mission_distribution.map((m) => ({ label: MISSION_LABELS[m.mission_type] ?? titleCase(m.mission_type), value: num(m.count) ?? 0 })).filter((x) => x.value > 0).sort((a, b) => b.value - a.value);47 const growth = d.growth.map((g) => {48 const launched = num(g.launched) ?? 0;49 const still = Math.min(launched, num(g.still_active) ?? 0);50 return { x: g.year, still_active: still, retired: Math.max(0, launched - still) };51 });5253 const jsonLd = {54 '@context': 'https://schema.org',55 '@type': 'Organization',56 name: d.name,57 alternateName: d.aliases,58 url: d.official_url ?? undefined,59 sameAs: d.official_url ? [d.official_url] : undefined,60 address: d.country_name ? { '@type': 'PostalAddress', addressCountry: d.country_code ?? d.country_name } : undefined,61 mainEntityOfPage: `${SITE_URL}${routes.operator(d.slug)}`,62 };6364 return (65 <Container wide>66 <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />6768 <header className="pb-6 pt-8 md:pb-8 md:pt-12">69 <nav aria-label="Breadcrumb" className="eyebrow">70 <Link href={routes.operators()} className="hover:text-ink">Operators</Link> <span aria-hidden>/</span> {d.name}71 </nav>72 <div className="mt-3 flex flex-wrap items-center gap-2">73 <Tag tone="accent">{titleCase(d.kind)}</Tag>74 {d.country_code && <Tag>{d.country_code}</Tag>}75 </div>76 <h1 className="display mt-3 text-3xl md:text-5xl">{d.name}</h1>77 <p className="mt-3 max-w-2xl text-[15px] text-ink-2 md:text-base">78 {d.country_slug ? <Link href={routes.country(d.country_slug)} className="link">{d.country_name}</Link> : 'Country unavailable'}79 {' '}· {fmtInt(d.active_payloads)} active payloads · {fmtInt(d.constellations)} {num(d.constellations) === 1 ? 'constellation' : 'constellations'}80 </p>81 {d.description && <p className="mt-4 max-w-3xl text-[15px] leading-relaxed text-ink-2">{d.description}</p>}82 <HeroFacts83 items={[84 { label: 'Official site', value: d.official_url ? <ExternalLink href={d.official_url} /> : null },85 { label: 'Also known as', value: d.aliases.length ? d.aliases.join(' · ') : null },86 ]}87 />88 </header>8990 <KpiStrip91 items={[92 { label: 'Active payloads', value: <span className="text-active">{fmtInt(d.active_payloads)}</span> },93 { label: 'On orbit', value: fmtInt(d.on_orbit_payloads) },94 { label: 'Total payloads', value: fmtInt(d.total_payloads) },95 { label: 'Decayed', value: fmtInt(d.decayed) },96 { label: 'Launches', value: fmtInt(d.launches) },97 { label: 'Launched 365 d', value: fmtInt(d.payloads_last_365d) },98 { label: 'Constellations', value: fmtInt(d.constellations) },99 { label: 'First launch', value: <span className="text-xl md:text-2xl">{fmtDate(d.first_launch)}</span> },100 { label: 'Last launch', value: <span className="text-xl md:text-2xl">{fmtDate(d.last_launch)}</span> },101 { label: 'Snapshot', value: <span className="text-base text-ink-2 md:text-lg">{fmtDateTime(generatedAt)}</span> },102 ]}103 />104105 <div className="grid gap-x-10 lg:grid-cols-[minmax(0,7fr)_minmax(0,4fr)]">106 <div className="min-w-0">107 <Block eyebrow="Growth" title="Fleet growth by launch year" id="growth">108 {growth.length ? (109 <StackedBars data={growth} keys={['still_active', 'retired']} labels={{ still_active: 'Launched · still active', retired: 'Launched · no longer active' }} title="Payloads launched per year, split by current status" height={200} />110 ) : (111 <Unavailable what="Fleet growth" />112 )}113 </Block>114 <Block eyebrow="Programmes" title={`Constellations · ${fmtInt(d.constellations)}`} id="constellations">115 <ConstellationsMiniTable rows={d.constellations_list} />116 </Block>117 <Block eyebrow="Launches" title={`Launch history · ${fmtInt(d.launches)} launches`} id="launches">118 <LaunchesTable rows={d.launches_list} />119 {d.launches_list.length < (num(d.launches) ?? 0) && <p className="mt-2 text-xs text-ink-3">Showing the {fmtInt(d.launches_list.length)} most recent launches.</p>}120 </Block>121 <Block eyebrow="Fleet" title="Fleet sample" id="fleet" action={{ href: routes.satellites(`operator=${encodeURIComponent(d.slug)}`), label: 'All satellites operated' }}>122 <SatellitesTable rows={d.fleet_sample} columns={['orbit', 'mission', 'perigee']} />123 </Block>124 <Block eyebrow="Timeline" title="Events" id="events" action={{ href: routes.events(`entity=${encodeURIComponent(d.id)}`), label: 'All events' }}>125 <EventsList events={d.events} />126 </Block>127 </div>128129 <aside className="min-w-0 lg:border-l lg:border-rule lg:pl-10">130 <Block eyebrow="Fleet" title="By status">131 <Donut data={statusData} title="Payloads by status" total={num(d.total_payloads) ?? undefined} size={140} />132 </Block>133 <Block eyebrow="Fleet" title={<span className="inline-flex items-center gap-2">By orbit class <Link href={routes.methodology()} className="text-[10px] font-semibold uppercase tracking-[0.12em] text-accent-2 hover:underline">derived</Link></span>}>134 <Donut data={orbitData} title="Payloads on orbit by orbit class" size={140} />135 </Block>136 <Block eyebrow="Fleet" title={<span className="inline-flex items-center gap-2">By mission <Link href={routes.methodology()} className="text-[10px] font-semibold uppercase tracking-[0.12em] text-accent-2 hover:underline">derived</Link></span>}>137 <HBars data={missionData} />138 </Block>139 <Block eyebrow="Ground" title="Launch sites">140 <SitesTable rows={d.launch_sites} />141 </Block>142 <Block eyebrow="Monitoring" title="Recent announcements">143 <PlannedUnavailable what="Recent announcements" note="Company monitoring (press releases, filings, official channels) is planned. Nothing is shown until a source is connected." />144 </Block>145 </aside>146 </div>147 </Container>148 );149}150